
昨天把一次答錯拆成七層。今天把手伸進第三層:向量本身。
我原本只想挑一顆 embedding。沒想到第一關,竟然不是分數,而是授權。
要的是能吃表格、圖表與掃描件的那種,同類裡下載量最高的是 jina-embeddings-v4(HF visual-document-retrieval 分類 467,674 次/月,2026-09-03 查)。
HF 頁面的 license 欄是空的:cardData.license 與 license_name 都回 null,空 tag 不觸發任何規則:掃描器看到 unknown,不是放行就是誤擋。往下讀 model card 的 ## License,它自己說 CC-BY-NC 是誤標。
完整條文藏在 repo 的 LICENSE:Qwen Research License,Section 2(a) 寫「…the Materials FOR NON-COMMERCIAL PURPOSES ONLY」,1(i) 把 Non-Commercial 定義成 research or evaluation。但 2(b) 留了一道門:商用要另外申請。所以精確的結論不是「不能用」,而是拿它撐正式的內部服務不在明示範圍內,得先去談。
三個官方入口,給了三種答案。既然如此,就只能查到底。

圖 1:前三格來自 Jina 的 repo、第 ④ 格來自 Qwen 的底模 repo——都是官方來源,卻拼不出一致的機器可讀答案。
第 ④ 格才是根因:底模 Qwen2.5-VL-3B-Instruct 的 license 欄同樣是空的,寫著 qwen-research 的是 license_name 這個更少人看的欄位。而且 jina-v4 連 base_model 都沒填,底模只在 model card 正文裡。沒有哪個機器可讀的欄位永遠靠得住。
通則是衍生模型的授權下限由底模決定。要講精確一點:在沒有另外向上游取得授權的前提下,底模給的是下限——上面的人可以再收緊,不能放寬。
同一家 jina 兩個方向都有:jina-embeddings-v5-text-small 的底模是 Apache-2.0,自己卻標 cc-by-nc-4.0(在文本上加限制,沒問題);jina-embeddings-v4-mlx-8bit 的 base_model 就寫著 v4,卻標 apache-2.0,比底模寬。比底模寬鬆的標示先當成標錯——量化版與移植版一樣是衍生。
那個前提不是空話。有人在 jina-v4 的 repo 問過,jina 的人回:他們確實聯絡了 Qwen 團隊、拿到發布同意,但「we got an agreement to publish the model, we don't have a license for its commercial use」【官方,HF discussions #44】。上游授權談得到一部分,沒談到的那部分還綁著。

圖 2:會被自動化掃描器放行的,正好是有問題的那兩條——它們最上面那一跳寫的都是 MIT。
23 顆裡有 20 顆找不到 LICENSE 檔,所以它不能當唯一入口——那些 repo 的 tag 反而是僅有的機器可讀證據。
所以動作是四個地方都拉一次,對不上往嚴的靠:tag、license_name、model card 的 License 段、LICENSE 檔,再沿底模做一次——這是上線前的風險控制,不是法律解釋。
放在地端,並不代表條款就此失效。 jina 在 v3 的 model card 上,把「on-premises within your company」直接算進 CC BY-NC 的管轄範圍。而 QRL 把 research/evaluation 列在明示許可裡——你今天要做的事落在其中。它卡的不是評估,是上線。算不算商用,最後是法務說了算。
順序倒過來的理由就在這:等你建完索引、跑完評估才撞上「要重談授權」,換一個向量空間就是全量重新編碼加重建索引。(縮維度是截斷、不必重跑模型,但向量仍要重寫、index 仍要重建。)
先別談第一名。連授權都過不了,分數就沒有討論的價值。
所以我的順序是:授權 → max length → 語言 → 尺寸 → 分數。
multilingual-e5-large 的 model card 白紙黑字寫著「Long texts will be truncated to at most 512 tokens」。但散文寫不進 CI,你要的是欄位。

圖 3:四欄依序是 config.json 的 max_position_embeddings、sentence_bert_config.json 的 max_seq_length、tokenizer_config.json 的 model_max_length、model card 宣告值。一致的三顆都是雙向 encoder(Granite 還是 2026 年的),打架的四顆全是從 LLM 改出來的——不是新舊的問題,是出身的問題。
前兩顆的 514 對 512、8194 對 8192 只差 2,那是 RoBERTa 系把位置從 pad_token_id + 1 起算,多出來兩格不能用,不是矛盾。
要小心的是 bge-code-v1:sentence_bert_config.json 寫 32,768,tokenizer_config.json 的 model_max_length 卻是 256。走 sentence-transformers 讀前者、切在 32,768;自己拿 AutoTokenizer 配 truncation=True 吃的是後者、切在 256。同一顆模型、同一份文字,兩條路差 128 倍。
至少,報錯還算坦白。真正麻煩的,是它早已截斷,卻什麼也不說。 走 sentence-transformers 或裸 tokenizer,它收到顯式的 max_length 加 truncation,後半段人間蒸發、不噴錯誤,只有 recall 悄悄少一截。走 Day 21 那套 vLLM 則相反:超長直接回錯誤,吵但安全。
⚠️ 但 vLLM 還有第三條路:enable_chunked_processing(預設 False)開了之後,超長輸入會被拆段、各自編碼再加權平均【官方,vLLM vllm/config/pooler.py】——不報錯了,但那不是模型一次讀完全文。那是另一個候選,不是把牆推開。
昨天那張表的 L3 驗證是 dense 與 BM25 分跑比 recall@50。截斷確實會讓 dense 難看,但你會把它記到「dense 對專有名詞爛」頭上。所以 L3 要多一條:印一次 max_seq_length,跟你的 chunk 長度比一比。
順帶收一條 Day 21 的線:那張預算表的 embedding 開 --max-model-len 1024,而規格上限是 32K——那個 1024 是顯存預算逼出來的。規格線靜靜切掉、預算線直接報錯、chunk 長度你自己給——三條擋你的方式不同,但都要取最小的那條。
榜首之間常常沒有統計顯著差異。 Lyon NLP 團隊拿 MTEB 的 raw results 做過統計檢定:2024-03 那個時點,法文榜前 9 名在 p=0.05 下統計等價,建議別看平均分、要看你 use case 那幾個子任務【引用,huggingface.co/blog/lyon-nlp-group/mteb-leaderboard-best-practices,2024-03 快照】。那是 26 個任務的平均,英文榜有 56 個——題目越少越看不出顯著。
榜是可以刷的。 同一份文件明講 data leakage 與 overfitting 會污染評估。MTEB 的對策寫進了 codebase,leaderboard 據此提供 zero-shot 過濾,而它預設是 Allow All——看榜的第一個動作不是往下捲,是把它切成 Only Zero-shot。
大模型也不一定贏。 MMTEB(500+ tasks、250+ 語言)的結論有兩半:數十億參數的 LLM 確實能在某些語言子集拿下 SOTA,但整體最佳的公開模型是 560M 的 multilingual-e5-large-instruct【引用,arXiv:2502.13595,v1 2025-02】。
中文要切到 C-MTEB。它不是另一個網站,是同一個 leaderboard 下拉選單裡的另一個 benchmark,今天叫 MTEB(cmn, v1)、31 個 task(C-Pack 論文當年是 6 tasks / 35 datasets【引用,arXiv:2309.07597】)。不切過去,你看的還是英文榜。
而繁中只以多語任務的 zho-Hant 子集零星存在;社群另有拿 DRCD 做的繁中評測,所以不是完全沒得跑——但那些語料都不是機械手冊、料號與台灣產業術語。這一關沒有現成答案可查,只能自建題庫。
過了前三關,才輪到「哪一顆」。這張不是排名,是過關之後我會先放進題庫的名單;最後一欄只回答「權重載不載得進」,不代表整套 workload 跑得動。
| 角色 | 模型 | 維度 / 有效上限 | 授權(括號是底模) | 16-bit 權重 GB | 載得進 12 GiB |
|---|---|---|---|---|---|
| 現役候選 · 中文多語 | ibm-granite/granite-embedding-311m-multilingual-r2 |
768 / 32K | Apache-2.0 | 0.62 | 可 |
| 現役候選 · 中文多語(1B 級) | nvidia/Nemotron-3-Embed-1B-BF16 |
2048 / 32K | OpenMDW 1.1,不是 Apache(底模 Ministral-3-3B 反而是 Apache-2.0) | 2.28 | 可 |
| 現役候選 · 程式碼 | BAAI/bge-code-v1 |
1536 / 32K(⚠️ tokenizer 256) | Apache-2.0 | 3.09 | 可 |
| 現役候選 · 表格與掃描件 | Qwen/Qwen3-VL-Embedding-2B |
2048 / 32K | Apache-2.0(Qwen3-VL-2B-Instruct 同) | 4.26 | 可 |
| 成熟基準 · 2024 世代 | BAAI/bge-m3 |
1024 / 8192 | MIT | 1.14 | 可 |
| 成熟基準 · chunk 短 | intfloat/multilingual-e5-large |
1024 / 512 | MIT | 1.12 | 可 |
| 系列對照 · Day 21 那格 | Qwen/Qwen3-Embedding-0.6B |
1024 / 32K | Apache-2.0(Qwen3-0.6B-Base 同) | 1.19 | 可 |
| 授權案例,不是推薦 | jina-embeddings-v4 | 2048 / 32K | QRL 非商用(Qwen2.5-VL-3B-Instruct 傳染) | 7.51 + 0.36 | 塞得下,卡在授權 |
讀表三條:
config.json 最大的那個數——後者對表上八顆有五顆更大。research/day23/NOTES.md §6。分組不是排版,是判準。下面三列留下的理由各不相同:BGE-M3 與 multilingual-e5-large 是成熟基準,生態最厚但權重停在 2024;Qwen3-Embedding-0.6B 留著是為了接回 Day 21 的顯存帳。三顆都不是這張表的優先候選,而 jina-v4 更只是案例。
心算一條就夠:實際參數量 × 2 bytes = 16-bit 權重(Day 05 那本 bpw 帳的極簡版)。重點在「實際」——名字裡的 1B、2B 是四捨五入後的行銷名,表上那顆 Nemotron 實抓 1.14B,2.28 GB 而不是 2.00。至於 checkpoint 出貨用什麼 dtype 與逐顆參數量,收在 NOTES。
這一欄也不是全部:non-KV runtime 與落在 util 預算之外的 CUDA context 都還沒進帳,Day 21 算過那兩欄。
權重載得進顯卡,只代表它有資格上場。能不能留下,還得回到 Day 21 的整卡預算。
BGE-M3 還留著,是因為它一顆同時輸出 dense、sparse 與 ColBERT 三種表徵(後兩路要用官方 FlagEmbedding),而且是 encoder、不需要跨 step 的 KV cache。另外,單向量與 late interaction 是兩條路:sentence-transformers 6.0 起把 multi-vector 收成一等公民,細節匹配更強,代價是索引大得多。
⚠️ 而且別為了長 chunk 去換 Qwen3-Embedding-0.6B:它是 decoder,一條 32K 序列的 KV 要 3.50 GiB(2 × 28 層 × 8 kv heads × 128 × 2 bytes × 32,768),是 Day 21 那格 0.31 GiB 的十一倍——在那張三張嘴的卡上,32K 是規格不是預算。換它的理由是授權:Apache-2.0 有明示的專利授權條款,MIT 沒有。
走到這裡候選通常只剩兩三顆,這時才用你自己的題庫分勝負。量的是 Hit@10:這一題的正確 chunk 有沒有任何一個進前十,中記 1、沒中記 0。
它跟昨天那道 recall 階梯不是同一個數:recall@k 的分母是「這題有幾個正確 chunk」,Hit@10 只問有沒有——配對檢定要的就是這種二元結果。
而這把尺很粗。同一組題、配對比較(exact McNemar,雙尾 α=0.05):
| 兩顆的真實差距 | 50 題的檢定力 | 200 題 | 80% 要幾題 |
|---|---|---|---|
| 5 pp | 6.9% | 37.3% | 498 |
| 10 pp | 24.1% | 87.5% | 168 |
| 20 pp | 69.9% | ~100% | 61 |
【推算,exact McNemar,假設不一致對比例 15 / 20 / 30%;算式在 research/verify-day23-numbers.py】
所以 golden set 不是拿來解析零點幾分的,那個解析度它沒有。而且連「一大截」都比想像中吃力:50 題對 20 pp 只有約七成機會驗出來;要把檢定力拉到 80%,20 pp 要 61 題、10 pp 要 168 題、5 pp 要 498 題。
名次換得多快?gte-Qwen2-7B 自稱 2024-06-16 中英榜都第一,Qwen 則在 2025-06-05 說 Qwen3-Embedding-8B 拿下 MTEB multilingual 第一——不同榜,但間隔將近一年。今天誰第一我沒有一手證據:leaderboard 是 Gradio space,抓下來只有空殼。
榜會換,tag 會改,LICENSE 甚至也改過。既然答案會變,就別背答案。把查法留下來。
前兩支只查 metadata,一台能上網的機器就夠、不下載權重;第三支會下載三顆模型並對你的語料做推論,CPU 跑得動,但實務上會想要一張卡。三支的完整可執行版都在 research/day23/,正文只留判斷方式。
# 1) 授權入口盤點: 四個欄位一起看, 印完把 base 餵回去再跑, 直到沒有底模
# 只找入口, 不判讀條款 —— LICENSE 原文仍要自己讀完
curl -s "https://huggingface.co/api/models/jinaai/jina-embeddings-v4" | python3 -c "
import sys, json
d = json.load(sys.stdin); c = d.get('cardData') or {}
print(c.get('license'), '|', c.get('license_name'), '| @', d['sha'][:7],
'|', [x['rfilename'] for x in d['siblings'] if 'LICEN' in x['rfilename'].upper()],
'|', c.get('base_model'))"
# None | None | @ 853c867 | ['LICENSE'] | None
# 四個欄位, 三個是 None —— 連底模都要自己去 model card 正文撈
這支只是看一眼;掃過 23 顆、含缺欄保護與 tags 裡另一個 base_model 藏身處的完整版是 license_probe.py。
# 2) 有效上限體檢: 先抽查 tokenizer 這一格, 不必載模型
# 三個檔不會互相同意, 而且還不是全部 —— 官方 card 有時比三個都小
curl -sfL "https://huggingface.co/BAAI/bge-code-v1/resolve/main/tokenizer_config.json" \
| python3 -c "import sys,json;print([f'{k}={v}' for k,v in json.load(sys.stdin).items() if 'max' in k])"
# ['model_max_length=256'] <- sentence_bert_config 與 config 各拉一次, 那兩個都是 32768
第三支要你自己的資料:把昨天那份 golden.jsonl 補到 50 題以上,欄位沿用不變——別另開一份,Day 24 一動 chunk size,id 就整批作廢。
名單是兩顆現役候選加一顆成熟基準;表格與掃描件那顆走多模態,題庫不一樣,不塞進同一組。
# 3) 比 Hit@10 + 配對 exact 檢定 (含載入與清理的完整版: research/day23/hit_at_k.py)
from math import comb # texts/ids 來自 chunks.jsonl, gold 來自昨天那份 golden.jsonl
K = 10; assert len(gold) >= 50 and len(texts) >= K # 檢定力由題數決定, 不是 chunk 數
CAND = [ # 名稱, doc 端前綴, query 端的官方用法 —— 前綴不是裝飾, 少了會系統性低估
("ibm-granite/granite-embedding-311m-multilingual-r2", "", {}),
("nvidia/Nemotron-3-Embed-1B-BF16", "passage: ", {"prompt": "query: "}),
("BAAI/bge-m3", "", {}), # 成熟基準
]
hits = {}
for name, dp, qkw in CAND:
m = SentenceTransformer(name)
D = m.encode([dp + t for t in texts], normalize_embeddings=True)
Q = m.encode([g["question"] for g in gold], normalize_embeddings=True, **qkw)
top = np.argpartition(-(Q @ D.T), K - 1, axis=1)[:, :K]
hits[name] = np.array([bool(set(g["gold_doc_ids"]) & {ids[i] for i in row}) # 交集, 不是相等
for g, row in zip(gold, top)])
print(name, f"Hit@{K} =", round(hits[name].mean(), 3))
def exact_p(b, c): # 只看兩顆結果不一致的那些題, 雙尾
n = b + c
return 1.0 if n == 0 else min(1.0, 2 * sum(comb(n, i) for i in range(min(b, c) + 1)) / 2 ** n)
for i, (x, *_) in enumerate(CAND):
for y, *_ in CAND[i + 1:]:
a, b = hits[x], hits[y]
na, nb = int((a & ~b).sum()), int((~a & b).sum())
print(x, "vs", y, na, nb, f"exact p = {exact_p(na, nb):.4f}")
兩兩比較那組才是重點:p 值大不代表兩顆一樣好,只代表題數還分不出來。
這一篇不附我的 Hit@10 數字:它由你的語料決定,附上去只會邀請你拿我的絕對值去對你的系統。要帶走的是那張檢定力表。
明天 Day 24〈一份 80MB 的 PDF,chunk 要切幾刀?英文有答案,中文我來補〉:今天量的是模型那端吃得下多長,明天從文件那端切過來,看中文到底該切幾刀。
那麼,明天見。